Skip to content

Add useAction for tracked async actions#1612

Open
mcintyre94 wants to merge 1 commit into
react/providerfrom
react/use-action
Open

Add useAction for tracked async actions#1612
mcintyre94 wants to merge 1 commit into
react/providerfrom
react/use-action

Conversation

@mcintyre94
Copy link
Copy Markdown
Member

@mcintyre94 mcintyre94 commented May 8, 2026

Summary of Changes

This PR adds the useAction hook in React. This bridges any async action into the ReactiveActionStore state machine:

function PostMessageButton({ url, body }: { url: string; body: string }) {
    const { send, isRunning, error } = useAction(async (signal, content: string) => {
        const res = await fetch(url, { body: content, method: 'POST', signal });
        if (!res.ok) throw new Error(`HTTP ${res.status}`);
        return res.json();
    });

    return (
        <button disabled={isRunning} onClick={() => send(body)}>
            {isRunning ? 'Posting…' : error ? 'Retry' : 'Post'}
        </button>
    );
}

The function passed to useAction doesn't need to be memoised etc, we handle that lifecycle internally with a ref.

The status comes from ReactiveActionStore and can be idle, running, success, error. The react hook adds boolean aliases isRunning etc. Later requests abort previous ones, and previous data remains available for SWR.

This is available for apps for any async functionality they need, and plugin hooks such as useSendTransaction will also be built on top of it.

@changeset-bot
Copy link
Copy Markdown

changeset-bot Bot commented May 8, 2026

🦋 Changeset detected

Latest commit: a9c5449

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 47 packages
Name Type
@solana/react Minor
@solana/accounts Minor
@solana/addresses Minor
@solana/assertions Minor
@solana/codecs-core Minor
@solana/codecs-data-structures Minor
@solana/codecs-numbers Minor
@solana/codecs-strings Minor
@solana/codecs Minor
@solana/compat Minor
@solana/errors Minor
@solana/fast-stable-stringify Minor
@solana/fixed-points Minor
@solana/functional Minor
@solana/instruction-plans Minor
@solana/instructions Minor
@solana/keys Minor
@solana/kit Minor
@solana/nominal-types Minor
@solana/offchain-messages Minor
@solana/options Minor
@solana/plugin-core Minor
@solana/plugin-interfaces Minor
@solana/program-client-core Minor
@solana/programs Minor
@solana/promises Minor
@solana/rpc-api Minor
@solana/rpc-graphql Minor
@solana/rpc-parsed-types Minor
@solana/rpc-spec-types Minor
@solana/rpc-spec Minor
@solana/rpc-subscriptions-api Minor
@solana/rpc-subscriptions-channel-websocket Minor
@solana/rpc-subscriptions-spec Minor
@solana/rpc-subscriptions Minor
@solana/rpc-transformers Minor
@solana/rpc-transport-http Minor
@solana/rpc-types Minor
@solana/rpc Minor
@solana/signers Minor
@solana/subscribable Minor
@solana/sysvars Minor
@solana/transaction-confirmation Minor
@solana/transaction-messages Minor
@solana/transactions Minor
@solana/wallet-account-signer Minor
@solana/webcrypto-ed25519-polyfill Minor

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

Copy link
Copy Markdown
Member Author

mcintyre94 commented May 8, 2026

Warning

This pull request is not mergeable via GitHub because a downstack PR is open. Once all requirements are satisfied, merge this PR as a stack on Graphite.
Learn more

This stack of pull requests is managed by Graphite. Learn more about stacking.

@bundlemon
Copy link
Copy Markdown

bundlemon Bot commented May 8, 2026

BundleMon

Files updated (10)
Status Path Size Limits
react/dist/index.native.mjs
3.89KB (+815B +25.73%) -
react/dist/index.browser.mjs
3.89KB (+814B +25.69%) -
react/dist/index.node.mjs
3.89KB (+814B +25.71%) -
errors/dist/index.browser.mjs
20.69KB (+170B +0.81%) -
errors/dist/index.native.mjs
20.69KB (+170B +0.81%) -
errors/dist/index.node.mjs
20.71KB (+170B +0.81%) -
wallet-account-signer/dist/index.native.mjs
17.53KB (+165B +0.93%) -
wallet-account-signer/dist/index.browser.mjs
17.53KB (+164B +0.92%) -
wallet-account-signer/dist/index.node.mjs
17.54KB (+164B +0.92%) -
@solana/kit production bundle
kit/dist/index.production.min.js
52.3KB (+31B +0.06%) -
Unchanged files (137)
Status Path Size Limits
rpc-graphql/dist/index.browser.mjs
18.82KB -
rpc-graphql/dist/index.native.mjs
18.81KB -
rpc-graphql/dist/index.node.mjs
18.81KB -
transaction-messages/dist/index.browser.mjs
11.32KB -
transaction-messages/dist/index.native.mjs
11.32KB -
transaction-messages/dist/index.node.mjs
11.32KB -
instruction-plans/dist/index.browser.mjs
6.58KB -
instruction-plans/dist/index.native.mjs
6.58KB -
instruction-plans/dist/index.node.mjs
6.58KB -
fixed-points/dist/index.browser.mjs
5.08KB -
fixed-points/dist/index.native.mjs
5.07KB -
fixed-points/dist/index.node.mjs
5.07KB -
codecs-data-structures/dist/index.browser.mjs
5.04KB -
codecs-data-structures/dist/index.native.mjs
5.03KB -
codecs-data-structures/dist/index.node.mjs
5.03KB -
offchain-messages/dist/index.browser.mjs
4.89KB -
offchain-messages/dist/index.native.mjs
4.89KB -
offchain-messages/dist/index.node.mjs
4.89KB -
transactions/dist/index.browser.mjs
4.07KB -
transactions/dist/index.native.mjs
4.07KB -
transactions/dist/index.node.mjs
4.07KB -
kit/dist/index.browser.mjs
3.97KB -
kit/dist/index.native.mjs
3.97KB -
kit/dist/index.node.mjs
3.97KB -
codecs-core/dist/index.browser.mjs
3.62KB -
codecs-core/dist/index.native.mjs
3.62KB -
codecs-core/dist/index.node.mjs
3.62KB -
webcrypto-ed25519-polyfill/dist/index.node.mj
s
3.61KB -
webcrypto-ed25519-polyfill/dist/index.browser
.mjs
3.59KB -
webcrypto-ed25519-polyfill/dist/index.native.
mjs
3.57KB -
rpc-subscriptions/dist/index.browser.mjs
3.37KB -
rpc-subscriptions/dist/index.node.mjs
3.34KB -
rpc-subscriptions/dist/index.native.mjs
3.31KB -
signers/dist/index.browser.mjs
3.26KB -
signers/dist/index.native.mjs
3.26KB -
signers/dist/index.node.mjs
3.26KB -
rpc-transformers/dist/index.browser.mjs
3.16KB -
rpc-transformers/dist/index.native.mjs
3.16KB -
rpc-transformers/dist/index.node.mjs
3.16KB -
keys/dist/index.node.mjs
3.06KB -
addresses/dist/index.browser.mjs
2.93KB -
addresses/dist/index.native.mjs
2.92KB -
addresses/dist/index.node.mjs
2.92KB -
keys/dist/index.browser.mjs
2.85KB -
keys/dist/index.native.mjs
2.85KB -
subscribable/dist/index.node.mjs
2.68KB -
subscribable/dist/index.native.mjs
2.61KB -
subscribable/dist/index.browser.mjs
2.6KB -
codecs-strings/dist/index.browser.mjs
2.55KB -
codecs-strings/dist/index.node.mjs
2.51KB -
codecs-strings/dist/index.native.mjs
2.47KB -
transaction-confirmation/dist/index.node.mjs
2.42KB -
transaction-confirmation/dist/index.native.mj
s
2.37KB -
sysvars/dist/index.browser.mjs
2.37KB -
sysvars/dist/index.native.mjs
2.37KB -
transaction-confirmation/dist/index.browser.m
js
2.37KB -
sysvars/dist/index.node.mjs
2.37KB -
rpc-subscriptions-spec/dist/index.node.mjs
2.25KB -
rpc-subscriptions-spec/dist/index.native.mjs
2.2KB -
rpc-subscriptions-spec/dist/index.browser.mjs
2.2KB -
rpc/dist/index.node.mjs
1.95KB -
codecs-numbers/dist/index.browser.mjs
1.95KB -
codecs-numbers/dist/index.native.mjs
1.95KB -
codecs-numbers/dist/index.node.mjs
1.94KB -
rpc-transport-http/dist/index.browser.mjs
1.91KB -
rpc-transport-http/dist/index.native.mjs
1.9KB -
rpc/dist/index.native.mjs
1.81KB -
rpc-types/dist/index.browser.mjs
1.8KB -
rpc/dist/index.browser.mjs
1.8KB -
rpc-types/dist/index.native.mjs
1.8KB -
rpc-types/dist/index.node.mjs
1.8KB -
rpc-transport-http/dist/index.node.mjs
1.72KB -
rpc-subscriptions-channel-websocket/dist/inde
x.node.mjs
1.33KB -
rpc-subscriptions-channel-websocket/dist/inde
x.native.mjs
1.27KB -
rpc-subscriptions-channel-websocket/dist/inde
x.browser.mjs
1.26KB -
program-client-core/dist/index.browser.mjs
1.21KB -
program-client-core/dist/index.native.mjs
1.21KB -
program-client-core/dist/index.node.mjs
1.21KB -
options/dist/index.browser.mjs
1.18KB -
options/dist/index.native.mjs
1.18KB -
options/dist/index.node.mjs
1.17KB -
accounts/dist/index.browser.mjs
1.17KB -
accounts/dist/index.native.mjs
1.17KB -
accounts/dist/index.node.mjs
1.16KB -
rpc-api/dist/index.browser.mjs
998B -
rpc-api/dist/index.native.mjs
997B -
rpc-api/dist/index.node.mjs
995B -
compat/dist/index.browser.mjs
969B -
compat/dist/index.native.mjs
968B -
compat/dist/index.node.mjs
966B -
rpc-spec-types/dist/index.browser.mjs
962B -
rpc-spec-types/dist/index.native.mjs
961B -
rpc-spec-types/dist/index.node.mjs
959B -
rpc-spec/dist/index.browser.mjs
918B -
rpc-spec/dist/index.native.mjs
918B -
rpc-spec/dist/index.node.mjs
917B -
rpc-subscriptions-api/dist/index.native.mjs
870B -
rpc-subscriptions-api/dist/index.node.mjs
869B -
rpc-subscriptions-api/dist/index.browser.mjs
868B -
promises/dist/index.native.mjs
841B -
promises/dist/index.node.mjs
840B -
promises/dist/index.browser.mjs
839B -
plugin-core/dist/index.browser.mjs
820B -
plugin-core/dist/index.native.mjs
819B -
plugin-core/dist/index.node.mjs
817B -
assertions/dist/index.browser.mjs
783B -
instructions/dist/index.browser.mjs
771B -
instructions/dist/index.native.mjs
770B -
instructions/dist/index.node.mjs
768B -
fast-stable-stringify/dist/index.browser.mjs
726B -
fast-stable-stringify/dist/index.native.mjs
725B -
assertions/dist/index.native.mjs
724B -
fast-stable-stringify/dist/index.node.mjs
724B -
assertions/dist/index.node.mjs
723B -
programs/dist/index.browser.mjs
329B -
programs/dist/index.native.mjs
327B -
programs/dist/index.node.mjs
325B -
fs-impl/dist/index.browser.mjs
245B -
event-target-impl/dist/index.node.mjs
230B -
functional/dist/index.browser.mjs
154B -
functional/dist/index.native.mjs
152B -
text-encoding-impl/dist/index.native.mjs
152B -
functional/dist/index.node.mjs
151B -
codecs/dist/index.browser.mjs
145B -
codecs/dist/index.native.mjs
144B -
codecs/dist/index.node.mjs
142B -
event-target-impl/dist/index.browser.mjs
133B -
ws-impl/dist/index.node.mjs
131B -
text-encoding-impl/dist/index.browser.mjs
122B -
fs-impl/dist/index.node.mjs
120B -
text-encoding-impl/dist/index.node.mjs
119B -
ws-impl/dist/index.browser.mjs
113B -
crypto-impl/dist/index.node.mjs
111B -
crypto-impl/dist/index.browser.mjs
109B -
rpc-parsed-types/dist/index.browser.mjs
66B -
rpc-parsed-types/dist/index.native.mjs
65B -
rpc-parsed-types/dist/index.node.mjs
63B -

Total files change +3.4KB +0.65%

Final result: ✅

View report in BundleMon website ➡️


Current branch size history | Target branch size history

@mcintyre94
Copy link
Copy Markdown
Member Author

@trevor-cortex

@github-actions
Copy link
Copy Markdown
Contributor

github-actions Bot commented May 8, 2026

Documentation Preview: https://kit-docs-aej7gzfvw-anza-tech.vercel.app

@mcintyre94 mcintyre94 changed the base branch from react/provider to graphite-base/1612 May 8, 2026 15:32
@mcintyre94 mcintyre94 force-pushed the graphite-base/1612 branch from 42064ae to 75196f0 Compare May 8, 2026 15:34
@mcintyre94 mcintyre94 changed the base branch from graphite-base/1612 to react/provider May 8, 2026 15:34
@mcintyre94 mcintyre94 changed the base branch from react/provider to graphite-base/1612 May 8, 2026 16:05
@mcintyre94 mcintyre94 force-pushed the graphite-base/1612 branch from 75196f0 to e7cf86e Compare May 8, 2026 16:05
@mcintyre94 mcintyre94 changed the base branch from graphite-base/1612 to react/provider May 8, 2026 16:05
@mcintyre94 mcintyre94 force-pushed the react/provider branch 2 times, most recently from afabea4 to a9d79e3 Compare May 8, 2026 16:17
@mcintyre94
Copy link
Copy Markdown
Member Author

@trevor-cortex

Copy link
Copy Markdown

@trevor-cortex trevor-cortex left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Summary

Adds useAction to @solana/react: a generic React hook that bridges any async function into a tracked action by wrapping createReactiveActionStore from @solana/subscribable. The hook is the React-side adapter for the existing framework-agnostic state machine — store is created once, the user's fn is held in a layout-effect-synced ref so the latest closure is invoked on every send(...) (no deps array), state is consumed via useSyncExternalStore, and the store is reset() on unmount to abort any in-flight call. The shape (status + data + error + derived is* booleans + stable send / reset) matches the supersede-on-second-call / stale-while-revalidate semantics already encoded in the underlying store.

Good, well-scoped PR. Nice docblocks and a thorough test suite covering all four state transitions, supersede semantics, SWR data preservation, fresh-closure capture, and reference stability of send/reset. Changeset is present and well-written; the tsconfig.json ESNext.PromiseES2024.Promise narrowing is appropriate (Promise.withResolvers landed in ES2024 and the tests rely on it). README placement under the foundational-hooks section before ## Hooks (wallet hooks) reads consistently with the existing layout.

Things to watch / discussion points

Nothing blocking — the points below are suggestions worth a glance:

  1. Store creation via useMemo([]) — React technically reserves the right to discard useMemo caches; the canonical "create once, never re-create" pattern for store-like objects is const [store] = useState(() => createReactiveActionStore(...)) (or useRef with lazy init). In practice this doesn't bite today, but useState makes the lifetime guarantee explicit and is what zustand, jotai, etc. use for the same situation. Inline comment with more detail.

  2. useLayoutEffect and SSRuseLayoutEffect emits the well-known no-op-on-server warning under React SSR. This package builds a Node entrypoint and useSyncExternalStore itself is SSR-safe, so consumers may render this in an SSR shell. The common workaround is const useIsoLayoutEffect = typeof window !== 'undefined' ? useLayoutEffect : useEffect. Worth confirming whether @solana/react considers SSR a target.

  3. Ref-staleness during the layout-effect window — the latest-fn ref is assigned in useLayoutEffect, which means a send(...) invoked from a child component's layout effect (which runs before the parent's) could still see the previous fn. Vanishingly unlikely in practice, and TanStack/wagmi have the same edge — flagging only for awareness.

For subsequent reviewers

  • Verify the useLayoutEffect choice against whatever SSR posture @solana/react targets — if SSR is supported, swap for the typeof window guarded variant.
  • Sanity-check the assumption baked into the silenced react-hooks/refs: it relies on createReactiveActionStore only invoking its callback inside dispatch/dispatchAsync, never during construction or getState. That matches the current implementation in packages/subscribable/src/reactive-action-store.ts, so the rule suppression is sound — but it's an invariant worth keeping in mind if that store ever gains eager behavior.
  • The keeps stable send / reset references test passes trivially today because both come straight off the store, which is created with [] deps. If point (1) above changes the construction strategy, that test still holds.

Comment thread packages/react/src/useAction.ts
Comment thread packages/react/src/useAction.ts Outdated
Comment thread packages/react/src/__typetests__/useAction-typetest.ts Outdated
@mcintyre94
Copy link
Copy Markdown
Member Author

@trevor-cortex

1 similar comment
@lorisleiva
Copy link
Copy Markdown
Member

@trevor-cortex

Copy link
Copy Markdown

@trevor-cortex trevor-cortex left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Re-review

Would approve if I had repo write access — GitHub blocks APPROVE reviews from accounts without explicit access, so leaving this as a comment.

All three points from the prior review are addressed:

  1. Store lifetimeuseMemo([])useState(() => createReactiveActionStore(...)). Lifetime is now an explicit React guarantee rather than a best-effort cache.
  2. SSR safetyuseIsomorphicLayoutEffect shim added with a clear inline comment explaining why falling back to useEffect on the server is functionally equivalent (no event handlers can fire during SSR, so the ref-sync timing doesn't matter there).
  3. Typetest — the trivial result.error satisfies unknown was dropped; the remaining assertions (ActionResult<...> shape, send(7) satisfies Promise<string>, data satisfies string | undefined) are all meaningful.

The eslint-disable react-hooks/refs suppression on the useState initializer is correct: fnRef.current is only read inside the dispatchAsync body in createReactiveActionStore, never at construction or during getState, so the rule's concern doesn't apply. The accompanying comment captures this invariant well.

LGTM from my side. 👍

Copy link
Copy Markdown
Member

@lorisleiva lorisleiva left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Looks good! Just one important comment IMO to avoid introducing two terms for the same concept.

Comment thread packages/react/src/useAction.ts Outdated
* `error`. Awaiters that read the resolved value (e.g. to navigate on success) should filter
* supersede rejections with `isAbortError` from `@solana/promises`.
*/
send: (...args: TArgs) => Promise<TResult>;
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should this not be dispatch to match the ReactiveActionStore API?

export type ReactiveActionStore<TArgs extends readonly unknown[], TResult> = {
    readonly dispatch: (...args: TArgs) => void;
    readonly dispatchAsync: (...args: TArgs) => Promise<TResult>;
    readonly getState: () => ReactiveActionState<TResult>;
    readonly reset: () => void;
    readonly subscribe: (listener: () => void) => () => void;
};

I feel like introducing a new dispatch term here could be more confusing than helping.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Changed to dispatch, I think this is mostly better. The one thing I'd flag is that it's actually mirroring dispatchAsync (ie you can await the result if you need to), but I think that's unlikely to be confusing in practice. I don't think there's value in distinguishing dispatch and dispatchAsync in the React layer, and I think using dispatchAsync would be confusing since it's mostly going to be fire-and-forget.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Agreed! Appreciate the heads up though. 🙏

@mcintyre94 mcintyre94 changed the base branch from react/provider to graphite-base/1612 May 12, 2026 12:28
@mcintyre94 mcintyre94 force-pushed the graphite-base/1612 branch from f473f42 to 0d2bb30 Compare May 12, 2026 12:58
@mcintyre94 mcintyre94 changed the base branch from graphite-base/1612 to react/provider May 12, 2026 12:58
@socket-security
Copy link
Copy Markdown

socket-security Bot commented May 12, 2026

All alerts resolved. Learn more about Socket for GitHub.

This PR previously contained dependency changes with security issues that have been resolved, removed, or ignored.

View full report

Bridges an arbitrary async function into a reactive `ActionResult<TArgs, TResult>` with `send` / `status` / `data` / `error` / `reset`. Each `send(...)` runs the function with a fresh `AbortSignal` and tracks its lifecycle through React state; a second `send` while a first is in flight aborts the first.

`fn` is held in a ref that always points at the latest render's closure — there is no `deps` array to maintain. Each `send(...)` invokes the most recently rendered `fn`, so values captured inside (form state, route params, etc.) are always fresh. This matches the convention used by `useMutation` in TanStack Query and `useWriteContract` in wagmi. In-flight calls are unaffected — they continue with the closure they captured at dispatch time.

Built on `createReactiveActionStore` from `@solana/subscribable`. Awaiters of a superseded call see a rejection with an `AbortError` filterable via `isAbortError` from `@solana/promises`.
@socket-security
Copy link
Copy Markdown

@socket-security
Copy link
Copy Markdown

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants